iT邦幫忙

2022 iThome 鐵人賽

DAY 23
2
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 23

[Day 23] 保持資料流 — 不要欺騙 hooks 的 dependencies(下)

  • 分享至 

  • xImage
  •  

函式與 dependencies

一種常見的誤解是認為函式不應該填寫在 dependencies 中。我們來看一下這個範例:

function SearchResults() {
  const [query, setQuery] = useState('react');
 
  async function fetchData() {
    const result = await axios(
      `https://foo.com/api/search?query=${query}`,
    );
    // ...
  }
 
  useEffect(
    () => {
      fetchData();
    },
    [] // deps 不誠實,effect 使用了內部沒有的變數「fetchData」
  );

  // ...
}

在以上的範例中,如果沒有一開始就誠實地把 fetchData 填到 effect 的 dependencies 的話,當我們在 fetchData 函式中加入其它的資料依賴時,你有可能就會因為忘記補上 dependencies 而遇到 bug 卻沒有事前就發現。

此時你的第一直覺反應可能是,那我就把這個 fetchData 誠實的寫進 dependencies,應該就可以解決了?

function SearchResults() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('react');
 
  async function fetchData() {
    const result = await axios(
      `https://foo.com/api/search?query=${query}`,
    );
    setData(result.data);
  }
 
  useEffect(
    () => {
      fetchData();
    },
    [fetchData] // 這個 deps 的效能最佳會永遠都會失敗,因為 fetchData 每次 render 時都不一樣
  ); 
  // ...
}

但其實這個範例中的 effect dependencies 效能最佳化永遠都會失敗。Effect 會在每個 render 後都被重新執行,這甚至比沒有寫 dependencies 的效能還要糟糕 — 畢竟比較值的動作還是需要花費效能的。

會有這樣的結果是因為 fetchData 這個變數其實在每次 render 都會是不同的。我們將這個函式宣告在 component 中,因此每次 render 時它都會重新被產生,並依賴該次 render 的資料。相信你還記得前面章節提到的這個觀念 — 每次 render 都有自己的 props、 state 以及 event handlers。

因此,當我們的 effect 依賴了一個函式卻又沒有做特別的處理時,就有可能會導致效能的最佳化失敗。這個問題的解決方法並不是欺騙 dependencies,我們會介紹幾種應對的方法:

把函式定義移到 effect 裡

承接上面的範例,如果你只在一個 effect 裡使用某個函式,其實可以把該函式直接放進那個 effect 裡面定義:

function SearchResults() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('react');
 
  useEffect(
    () => {
      // 把函式定義移到 useEffect 中
      // 這個函式就只有在 effect 執行時才會重新產生
      async function fetchData() {
        const result = await axios(
          `https://foo.com/api/search?query=${query}`,
        );
        setData(result.data);
      }
 
      fetchData();
    },
    [query] // 這裡的 dependencies 是誠實的
  );
  // ...
}

將函式搬進 effect 中能夠讓這個函式避免隨著 re-render 在不必要的時候重新產生。此時因為函式是寫在 effect 裡的,因此它就不會是一個依賴,而是改成直接依賴的真正要連動的資料 query

需要稍微提醒一下的是, useEffect 的第一個參數函式不可以是一個 async function,因此在這個範例中你必須先在 effect function 內部宣告一個 async function,才能在這個 async function 內部使用 await 語法。當然,你也可以乾脆改成以 promise.then 來寫。

但我不想將這個函式放進 effect 裡

有時候我們可能會不想要把函式直接定義在 effect 裡,像是在同元件的不同 effect 中都呼叫這個函式時,我們不想要複製貼上這段邏輯:

function SearchResults() {
  async function fetchData(query) {
    const result = await axios(
      `https://foo.com/api/search?query=${query}`,
    );
  }
 
  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實,但是 fetchData 函式每次 render 時都會重新產生,
    // 因此這個 effect deps 的效能最佳化完全無效
    [fetchData]
  );
 
  useEffect(
    () => {
      fetchData('vue').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實,但是 fetchData 函式每次 render 時都會重新產生,
    // 因此這個 effect deps 的效能最佳化完全無效
    [fetchData]
  );
  
  // ...
}

同樣的,在這個範例中當我們將 fetchData 填進 dependencies 後,雖然我們對他誠實了,但這個效能最佳化是永遠失敗的!

解決方案一:把與 component 資料流無關的流程抽到 component 外部

如果一個函式或是部分的流程沒有使用到任何 component 內的資料或依賴的話,你其實可以把它們移到 component 的外面:

async function fetchData(query) {
  const result = await axios(
    `https://foo.com/api/search?query=${query}`,
  );
  return result;
}
 
function SearchResults() {
  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實,因為 fetchData 是一個在 component 外部永遠不會改變的函式
    []
  );
 
  useEffect(
    () => {
      fetchData('vue').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實,因為 fetchData 是一個在 component 外部永遠不會改變的函式
    []
  );
  // ...
}

此時我們就可以不用將這個函式列在 effect 的 dependencies 裡,因為它並不是定義在 component 當中,因此並不會隨著每次 render 而重新產生,也就不會被資料流所連動影響 — 它不可能直接以 closure 的方式依賴 props 或 state。

解決方案二:把 useEffect 依賴的函式以 useCallback 包起來

你應該優先嘗試上面介紹的將函式抽到 component 外的做法。不過有時候你的函式有可能會依賴許多 component 中的資料,此時如果將函式抽到 component 外部的話反而會需要傳遞過多的參數,讓資料流的可讀性下降。

因此,React 其實有內建的配套措施可以幫助我們解決這個問題 — useCallback

useCallback 就像是資料流中連動反應的另一層檢查。當 useCallback 的 dependencies 中的依賴與前一次 render 時都相同時,它會返回前一次 render 版本的函式。useCallback 本身的這個行為對於「避免每次 render 重複產生函式」其實並沒有任何幫助,因為 component 在每次 render 仍然每次都會產生一個新的 inline function 之後才傳給 useCallback。然而,當這個效果搭配 useEffect 使用時則可以對 effect 的 dependencies 效能最佳化大有幫助!

function SearchResults(props) {
  const fetchData = useCallback(
    async (query) => {
      const result = await axios(
        `https://foo.com/api/search?query=${query}&rowsPerPage=${props.rows}`,
      );
      return result;
    },
    [props.rows] // callback deps 誠實
  );
 
  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // effect deps 是誠實的,
    // 且只有當 props.rows 不同時,fetchData 才會被重新產生,連帶的此時 effect 才會再次被執行。
    // 而如果 props.rows 沒有改變時,useCallback 就會回傳與前一次 render 相同的函式,
    // 則連帶的這個 effect 就會被忽略。
    // 因此這裡的 effect deps 效能最佳化可以正常發揮效果
    [fetchData]
  );
 
  // ...

在上面這個範例中,我們將 fetchData 函式直接定義在 component 中,函式裡依賴了 props.rows 資料,且會在 effect 中被呼叫。如果我們沒有使用 useCallbackfetchData 包起來的話,effect 就會因為 fetchData 在每次 render 時都不同而永遠最佳化失敗。而如果我們 fetchDatauseCallback 包起來的話,這個函式就能夠參與到 component 的「dependencies chain」當中了。

只有當 props.rows 不同時,fetchData 才會被重新產生,連帶的此時 effect 才會再次被執行。而如果 props.rows 沒有改變時,useCallback 就會回傳與前一次 render 相同的函式,則連帶的這個 effect 就會在該次 render 時被略過。因此這裡的 effect dependencies 效能最佳化可以正常發揮效果,它就像是一個資料流的連鎖反應一樣。

有了 useCallback的輔助之下,函式完全可以參與資料流。如果函式所依賴的資料有改變的話,函式才會跟著改變,而如果依賴不變的話,它會保持與前一次 render 時是相同的函式。而 useMemo 也是類似的應用概念。可以稍微補充的是,我們並不需要將所有 component 內的函式都以 useCallback 包起來,而是當這個函式會被使用在 effect 中,或是作為 React.memo 的比較的 props 傳遞時,再使用 useCallback 就好。

以上的範例與解析為了我們更明確地展現出了一個概念:

函式在 function component 與 hooks 中是屬於資料流的一部份。

useCallback & useMemo 可以讓讓由原始資料產生出來的延伸資料也能夠參與資料流,並配合 dependencies chain 在維持 useEffect 的同步可靠性的同時,也兼顧 effect 的效能最佳化。


Hooks exhaustive-deps linter rule

在目前為止的篇幅中,我們解析了為什麼應該永遠對 hooks 的 dependencies 保持誠實,以及一些如何安全的減少或調整 effect dependencies 的方法。然而,即使我們有意對 dependencies 保持誠實,但實際開發時總還是會遇到有所遺漏的時候。不過慶幸的是,React 官方有提供專門幫助你偵測甚至自動修正 hooks dependencies 的 linter rule 工具,能夠幫助我們在開發階段就透過靜態分析提前找出問題,非常推薦所有 React 開發者都應該使用這個輔助工具。

https://i.imgur.com/tSQ43WD.gif

這個 linter rule 已經內建在 Create React App 當中,因此如果你是透過其建立專案的話,應該就能在支援的編輯器中使用。當然,你也可以另外自行安裝。


參考資料

  • A Complete Guide to useEffect - Overreacted
    • 本文所講的觀念蠻多的參考了這篇 Dan Abramov 的個人 blog 文章,吸收內化後再加上我自己的理解以及整理來解釋這些概念。非常推薦所有英文程度 ok 的 React 開發者去他的 blog 中閱讀原文

2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 22] 保持資料流 — 不要欺騙 hooks 的 dependencies(上)
下一篇
[Day 24] useEffect dependencies 的經典錯誤用法
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
lovewith80871
iT邦新手 5 級 ‧ 2022-11-07 02:43:20

Zet 大大您好,
想問您第二段的程式,那如果我把 dep array 的 fetchData 直接換成 query 是不是一樣也能成功執行呢?還是這樣寫會有什麼其他 effect 在裡面呢?

function SearchResults() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('react');
 
  async function fetchData() {
    const result = await axios(
      `https://foo.com/api/search?query=${query}`,
    );
    setData(result.data);
  }
 
  useEffect(
    () => {
      fetchData();
    },
    [fetchData] // 這個 deps 的效能最佳會永遠都會失敗,因為 fetchData 每次 render 時都不一樣
  ); 
  // ...
}
Zet iT邦新手 2 級 ‧ 2022-11-08 08:00:26 檢舉

你好,在這個範例中將 useEffect 的 dependencies 改成填 query 的話,原則上最佳化會有效果,但是這種寫法會有一些負面影響:

  1. 當你的 fetchData 事後發生改寫,而使用到更多其他依賴資料時,若這個 useEffect 的 dependencies 如果沒有跟著加上對應的新依賴的話,則 effect 的效能最佳化就會有問題,而這通常很容易被開發者給遺漏
  2. 如果你有使用 linter rule 來幫助你檢查 hooks dependencies 的話,linter 會報錯,因為實際上 effect 直接依賴的是 fetchData 而不是 query。而一旦你將 linter 關掉,就有可能會遇到上面第一點的問題

因此,原則上非常不建議你嘗試這樣寫。直接保持 dependencies 的誠實,讓函式參與資料流,並且使用 linter rule 作為輔助檢查,會是更安全直覺且不容易發生疏漏的方式。

非常謝謝 zet 大大的回覆,

關於您回覆的第一點,以及配合您最後一段的範例 Hooks exhaustive-deps linter rule,也是將 query 放在 dep array。我看當中的差異只有 fetchData 是否在 useEffect 內定義,那這樣的做法豈不是也會在之後開發時遺漏新的依賴呢?

我要留言

立即登入留言